Skip to content

fix: require Fix entity for FIXED status on gated opport… - #3256

Open
anshulk-public wants to merge 3 commits into
mainfrom
fix/suggestion-fixed-requires-fix-entity
Open

fix: require Fix entity for FIXED status on gated opport…#3256
anshulk-public wants to merge 3 commits into
mainfrom
fix/suggestion-fixed-requires-fix-entity

Conversation

@anshulk-public

@anshulk-public anshulk-public commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Problem

Engineers are able to manually change a Suggestion's status to FIXED via the public
API without ever creating a Fix entity for it. patchSuggestion (single) and
patchSuggestionsStatus (bulk) accept status: FIXED directly, with no check that a
corresponding Fix exists. The only guards in place today — a generic status-transition
table (default warn, not enforce) and an admin gate scoped to REJECTED — don't
know or care whether a Fix backs the transition. Nowhere in this repo's
suggestion-status code paths is Fix.create even called; fix creation and suggestion
status updates are entirely disjoint operations today.

We want to block this — but only for the opportunity types where a Fix entity is
actually expected to exist
before a suggestion can be FIXED. Not every opportunity
type has a code path that produces a Fix (e.g. purely metric-derived resolutions), so
a blanket block would be wrong; it needs to be scoped to the types where "FIXED
without a Fix" is actually a data-integrity violation.

Jira: https://jira.corp.adobe.com/browse/SITES-50648

Constraint: fix this at the API layer, not the data-access layer

The obvious place to enforce "FIXED requires a Fix" as a hard invariant is the
data-access layer (@adobe/spacecat-shared-data-access, in Suggestion.setStatus)
— that's the one chokepoint every writer passes through. But that same data-access
layer is also used directly by the autofix worker (and potentially other future
consumers), not just this API. Changing the invariant there means changing behavior
for every consumer at once, in a separate package with its own release cycle, and
would need its own design pass (does the autofix worker's own internal flows always
satisfy the invariant already? does every opportunity type actually need it, or does
the enforcement need an escape hatch?).

We're keeping this change scoped to this repo's API layer only — the two public
PATCH endpoints — rather than the shared data-access layer. That means:

  • The autofix worker's own internal ordering issues (some handlers create the Fix
    before marking FIXED, some do it in the reverse order with no atomicity) are a
    separate, out-of-scope problem for a different repo.
  • The block only applies to requests coming through this API's public routes, not to
    every possible writer of Suggestion.status.

Solution

1. Block direct client transitions to FIXED, scoped by opportunity type.
A new constant, SUGGESTION_TYPES_REQUIRING_FIX_ENTITY
(src/utils/suggestion-fix-required-types.js), lists exactly the opportunity types
where a Fix is expected before FIXED. patchSuggestion and patchSuggestionsStatus
each check: if the target status is FIXED and the suggestion's opportunity type is in
this list, reject with 400 and point the caller at the fixes endpoint instead. The
check is duplicated in both handlers (they're independent routes, neither delegates
to the other) — following the same pattern already used for the existing
REJECTED-transition check in both. generic-opportunity is excluded from the list
since it's a shared fallback type used by several unrelated flows, not one semantic
type — blocking it would over-restrict suggestions that happen to share that
fallback bucket for unrelated reasons.

2. Give callers a real way to satisfy the requirement: create/update the Fix and
transition the suggestion atomically, in one request.

POST .../opportunities/:opportunityId/fixes (createFixes) and
PATCH .../opportunities/:opportunityId/status (patchFixesStatus) now accept an
optional suggestionsTargetStatus field. When present, once the fix write succeeds,
its linked suggestions are transitioned to that status in the same request — so a
suggestion is never transitioned without a fix already persisted behind it. Extending
these existing endpoints (rather than adding a new route) was the natural fit:
createFixes already accepted suggestionIds and already linked them; cascading a
status change on success is additive to what it already does, and rollbackFailedFix
in this same file already sets a precedent for this file touching suggestion status
as a side effect of a fix operation (it sets suggestions to SKIPPED on rollback). A
new route would just duplicate the existing access control, ownership checks, and
dedup logic for no semantic gain.

suggestionsTargetStatus takes an actual status value rather than a boolean flag
(e.g. markSuggestionsFixed: true), so the contract isn't hardcoded to FIXED and
doesn't need a second flag if some other status ever needs the same atomic guarantee.
Validation of the value itself isn't duplicated in this repo — bulkUpdateStatus in
the shared data-access layer already throws on an invalid status, and the existing
error-mapping in both endpoints handles that.

3. This API is also what Success Studio UI calls for "mark as deployed" — so the
frontend flow needs to change too.

The UI's createOpportunityFixes, setFixToDeployed, and setStatusToDeployedScoped
flows previously called this same fixes endpoint, then made a second, separate
patchSuggestionStatus PATCH call to flip the suggestion to FIXED — non-atomic, so a
failure or race between the two calls could leave a suggestion FIXED with no Fix (the
exact bug this PR closes on the API side) or a Fix with no updated suggestion. That
frontend flow is updated in a companion PR (OneAdobe/experience-success-studio-ui#2277)
to pass suggestionsTargetStatus on the same request instead of making a second call
— since a customer-facing UI action was itself part of the original problem, it has to
move in lockstep with the API change, not be left calling the old two-step pattern
against a backend that will start rejecting the second step for gated types.

Test plan

  • npm test — full suite passing
  • npm run lint — clean
  • npm run docs:lint — valid, no new warnings
  • New unit tests: the blocklist util, the FIXED-guard on both PATCH endpoints
    (gated vs. non-gated type), suggestionsTargetStatus behavior on both fix
    endpoints (atomic success, absent-field no-op, ownership-check regression,
    invalid-suggestion-ID fast-fail without orphaning a Fix)

🤖 Generated with Claude Code

Please ensure your pull request adheres to the following guidelines:

  • make sure to link the related issues in this description. Or if there's no issue created, make sure you
    describe here the problem you're solving.
  • when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes

If the PR is changing the API specification:

  • make sure you add a "Not implemented yet" note the endpoint description, if the implementation is not ready
    yet. Ideally, return a 501 status code with a message explaining the feature is not implemented yet.
  • make sure you add at least one example of the request and response.

If the PR is changing the API implementation or an entity exposed through the API:

  • make sure you update the API specification and the examples to reflect the changes.

If the PR is introducing a new audit type:

  • make sure you update the API specification with the type, schema of the audit result and an example

Related Issues

Thanks for contributing!

…unity types

Customers could PATCH a suggestion's status directly to FIXED via the public
API with no corresponding Fix entity ever created, leaving orphaned FIXED
suggestions with no audit trail of what fixed them.

- Add SUGGESTION_TYPES_REQUIRING_FIX_ENTITY blocklist covering opportunity
  types where a Fix entity is expected before a suggestion can be FIXED.
- patchSuggestion and patchSuggestionsStatus now reject (400) a direct
  transition to FIXED for these types, pointing callers at the fixes endpoint.
- POST .../opportunities/:opportunityId/fixes (createFixes) and
  PATCH .../opportunities/:opportunityId/status (patchFixesStatus) now accept
  an optional suggestionsTargetStatus field: once a fix is successfully
  created/updated, its linked suggestions are atomically transitioned to that
  status, so a suggestion is never marked FIXED without a persisted Fix.
- Add an ownership check on suggestionIds in createFixes (previously only
  patchFix validated this), and validate before creating the fix so an
  invalid suggestion ID fails fast without leaving an orphaned FixEntity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@anshulk-public anshulk-public changed the title fix(suggestions): require Fix entity for FIXED status on gated opport… fix: require Fix entity for FIXED status on gated opport… Sep 10, 2026
…tus is set

A Copilot review on the companion frontend PR flagged that the atomic
suggestionsTargetStatus request updates a suggestion server-side but the
frontend had no way to reflect that locally without a separate refetch,
since createFixes/patchFixesStatus responses only ever returned the fix,
never the suggestions it just transitioned.

- createFixes and #patchFixStatus now capture bulkUpdateStatus's return value
  (previously discarded) and include it as an optional `suggestions` field
  on the response entry, serialized via SuggestionDto.toJSON.
- The field is present only when suggestionsTargetStatus was provided and
  suggestions were actually transitioned as a result — callers that didn't
  mutate suggestion status get no suggestions field, and no extra DB read
  (getSuggestionsByFixEntityId) happens for callers that don't use the flag.
- Updated FixOperationSuccess OpenAPI schema (shared by both endpoints'
  responses) to document the new optional field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR will trigger a patch release when merged.

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@anshulk-public
anshulk-public requested review from MysticatBot and removed request for MysticatBot September 10, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant